﻿using System;
using System.Globalization;
using System.Net;
using System.Windows;
using System.Windows.Browser;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;

namespace HDViewSL
{
	/// <summary>
	/// The user interface for HD View SL.
	/// </summary>
	public partial class Page : UserControl
	{
		/// <summary>
		/// Occurs when the page has been laid out and an image has been loaded.
		/// </summary>
		public event EventHandler Ready;

		/// <summary>
		/// Occurs when the page receives mouse or keyboard input.
		/// </summary>
		public event EventHandler ReceivedInput;

		/// <summary>
		/// Occurs whenever the view (center or zoom) changes.
		/// </summary>
		public event EventHandler ViewChanged;

		// Constants:
		private const double DefaultMaxPixelMagnification = 2;	// maximum factor by which actual image pixels can be magnified
		private const double DefaultMaxZoom = 8;				// maximum zoom level used when actual image size is unknown
		private const double DefaultMinZoom = 1;				// minimum zoom level used for normal display mode
		private const double ZoomChangeFactor = 1.2;			// factor by which zoom changes when zooming in or out
		private const double MinEmptyMargin = 5;				// minimum number of pixels used for empty margin
		private const double EmptyMarginFraction = 0.05;		// fraction of the window used for empty margin
		private const double AngleToleranceFor360 = 0.3;		// angular tolerance (in degrees) for detecting 360-degree panoramas
		private const double DragCoefficient = 0.9;				// coefficient of friction for panning and zooming
		private const double PixelChangeThreshold = 0.25;		// number of pixels change required to keep animating
		private const double ZoomChangeThreshold = 1e-4;		// relative zoom change required to keep animating

		// Fields:
		private string source;
		private bool isDragging;
		private bool isOpen;
		private double minZoom = DefaultMinZoom;
		private double maxZoom = DefaultMaxZoom;
		private double maxPixelMagnification = DefaultMaxPixelMagnification;
		private Point mousePosition;
		private double emptyMargin = 10;
		private DisplayMode displayMode = DisplayMode.Normal;
		private MultiScaleImage multiScaleImage2 = new MultiScaleImage();
		private bool isRenderNeeded;
		private Point currentCenter;
		private double currentZoom;
		private Point targetCenter;
		private double targetZoom;
		private bool isViewportInitialized;
		private double elementWidth;
		private double elementHeight;
		private double imageAspectRatio;
		private double viewportScale = 1;
		private bool isViewportScaleOutdated;
		private bool isTargetCenterOutdated;
		private WebClient webClient;

		/// <summary>
		/// Constructs a new instance of the Page class.
		/// </summary>
		public Page()
		{
			// Initialize from XAML.
			this.InitializeComponent();

			// Turn off MultiScaleImage's built-in springs.  We'll update the viewports
			// ourselves, instead.
			this.multiScaleImage.UseSprings = false;
			this.multiScaleImage2.UseSprings = false;

			// Set up event handlers.
			this.KeyDown += this.Page_KeyDown;
			this.homeButton.Click += this.HomeButton_Click;
			this.zoomInButton.Click += this.ZoomInButton_Click;
			this.zoomOutButton.Click += this.ZoomOutButton_Click;
			this.fullScreenButton.Click += this.FullScreenButton_Click;
			this.toolbar.MouseEnter += this.Toolbar_MouseEnter;
			this.toolbar.MouseLeave += this.Toolbar_MouseLeave;
			this.multiScaleImage.ImageOpenFailed += this.MultiScaleImage_ImageOpenFailed;
			this.multiScaleImage.ImageFailed += this.MultiScaleImage_ImageFailed;
			this.multiScaleImage.ImageOpenSucceeded += this.MultiScaleImage_ImageOpenSucceeded;
			this.multiScaleImage.SizeChanged += this.MultiScaleImage_SizeChanged;
			this.multiScaleImage.MouseLeftButtonDown += this.MultiScaleImage_MouseLeftButtonDown;
			this.multiScaleImage2.MouseLeftButtonDown += this.MultiScaleImage_MouseLeftButtonDown;
			this.multiScaleImage.MouseMove += this.MultiScaleImage_MouseMove;
			this.multiScaleImage2.MouseMove += this.MultiScaleImage_MouseMove;
			this.multiScaleImage.MouseLeftButtonUp += this.MultiScaleImage_MouseLeftButtonUp;
			new MouseWheelHelper(this.multiScaleImage).MouseWheel += this.MultiScaleImage_MouseWheel;
			new MouseWheelHelper(this.multiScaleImage2).MouseWheel += this.MultiScaleImage_MouseWheel;
		}

		/// <summary>
		/// The width of the full-resolution image.
		/// </summary>
		/// <remarks>Determined automatically when reading an HD View XML file, but must be
		/// set manually when using a Deep Zoom XML file.</remarks>
		public int ImageWidth { get; set; }

		/// <summary>
		/// The height of the full-resolution image.
		/// </summary>
		/// <remarks>Determined automatically when reading an HD View XML file, but must be
		/// set manually when using a Deep Zoom XML file.</remarks>
		public int ImageHeight { get; set; }

		/// <summary>
		/// The aspect ratio (width divided by height) of the full-resolution image.
		/// </summary>
		/// <remarks>Determined once we've read an HD View XML file or loaded a Deep Zoom
		/// XML file.</remarks>
		public double ImageAspectRatio
		{
			get { return this.imageAspectRatio; }
			private set
			{
				this.imageAspectRatio = value;
				this.isViewportScaleOutdated = true;
				this.IsRenderNeeded = true;
			}
		}

		/// <summary>
		/// The location of the source XML (either HD View XML or Deep Zoom XML).
		/// </summary>
		public string Source
		{
			get { return this.source; }
			set
			{
				this.source = value;
				this.ProcessSourceChange();
			}
		}

		/// <summary>
		/// The current center point of the view (in logical coordinates).
		/// </summary>
		public Point Center
		{
			get { return this.currentCenter; }
			set
			{
				value = this.ClosestPoint(value);
				value = this.ClampCenter(value, this.currentZoom);
				if (this.currentCenter != value)
				{
					this.currentCenter = this.targetCenter = value;
					this.IsRenderNeeded = true;
				}
			}
		}

		/// <summary>
		/// The current zoom.
		/// </summary>
		public double Zoom
		{
			get { return this.currentZoom; }
			set
			{
				value = this.ClampZoom(value);
				if (this.currentZoom != value)
				{
					this.currentZoom = this.targetZoom = value;
					this.currentCenter = this.targetCenter = this.ClampCenter(this.currentCenter, this.currentZoom);
					this.IsRenderNeeded = true;
				}
			}
		}

		/// <summary>
		/// The center point of the desired view (in logical coordinates) after animation.
		/// </summary>
		public Point TargetCenter
		{
			get { return this.targetCenter; }
			set
			{
				value = this.ClosestPoint(value);
				if (this.targetCenter != value)
				{
					this.targetCenter = value;
					this.isTargetCenterOutdated = true;
					this.IsRenderNeeded = true;
				}
			}
		}

		/// <summary>
		/// The zoom of the desired view after animation.
		/// </summary>
		public double TargetZoom
		{
			get { return this.targetZoom; }
			set
			{
				value = this.ClampZoom(value);
				if (this.targetZoom != value)
				{
					this.targetZoom = value;
					this.isTargetCenterOutdated = true;
					this.IsRenderNeeded = true;
				}
			}
		}

		/// <summary>
		/// The bounds of the current image in logical coordinates.
		/// </summary>
		public Rect LogicalBounds { get; private set; }

		/// <summary>
		/// The current HD View image set.
		/// </summary>
		public HDViewImageSet ImageSet { get; private set; }

		/// <summary>
		/// The mode used to display the image: Normal, Horizontal360, or Vertical360.
		/// </summary>
		public DisplayMode DisplayMode
		{
			get { return this.displayMode; }
			set
			{
				this.displayMode = value;
				if (this.displayMode == DisplayMode.Normal)
				{
					this.layoutRoot.Children.Remove(this.multiScaleImage2);
				}
				else if (!this.layoutRoot.Children.Contains(this.multiScaleImage2))
				{
					var index = this.layoutRoot.Children.IndexOf(this.multiScaleImage);
					this.layoutRoot.Children.Insert(index, this.multiScaleImage2);
				}
				this.minZoom = (this.displayMode == DisplayMode.Normal ? DefaultMinZoom : 1);
				this.isViewportScaleOutdated = true;
				this.IsRenderNeeded = true;
			}
		}

		/// <summary>
		/// The current background color.
		/// </summary>
		public Color BackgroundColor
		{
			get { return ((SolidColorBrush)this.layoutRoot.Background).Color; }
			set { this.layoutRoot.Background = new SolidColorBrush(value); }
		}

		/// <summary>
		/// The current foreground color (used for message text).
		/// </summary>
		public Color ForegroundColor
		{
			get { return ((SolidColorBrush)this.messageTextBlock.Foreground).Color; }
			set { this.messageTextBlock.Foreground = new SolidColorBrush(value); }
		}

		/// <summary>
		/// A flag indicating whether anything has changed that requires a new rendering.
		/// </summary>
		/// <remarks>This property is set to true whenever user interaction occurs.  We
		/// then listen to the CompositionTarget.Rendering event, which tells us when to
		/// update the elements on screen.  After a period of inactivity, we stop
		/// listening to the CompositionTarget.Rendering event in order to reduce CPU
		/// load.</remarks>
		public bool IsRenderNeeded
		{
			get { return this.isRenderNeeded; }
			private set
			{
				// Turn rendering on or off as needed.
				if (this.isRenderNeeded != value)
				{
					this.isRenderNeeded = value;
					if (this.isRenderNeeded)
					{
						// Start listening for Rendering events.
						CompositionTarget.Rendering += this.CompositionTarget_Rendering;
					}
					else
					{
						// Stop listening for Rendering events.
						CompositionTarget.Rendering -= this.CompositionTarget_Rendering;
					}
				}
			}
		}

		/// <summary>
		/// Handles KeyDown events for the whole page.
		/// </summary>
		private void Page_KeyDown(object sender, KeyEventArgs e)
		{
			if (!e.Handled && this.isOpen)
			{
				switch (e.Key)
				{
					case Key.F:
						this.ToggleFullScreen();
						break;

					case Key.Home:
						this.ShowAll();
						break;

					case Key.Left:
						this.Pan(50, 0);
						break;

					case Key.Up:
						this.Pan(0, 50);
						break;

					case Key.Right:
						this.Pan(-50, 0);
						break;

					case Key.Down:
						this.Pan(0, -50);
						break;

					case Key.Add:
					case Key.PageUp:
						this.ZoomIn();
						break;

					case Key.Subtract:
					case Key.PageDown:
						this.ZoomOut();
						break;

					case Key.Unknown:
						if (e.PlatformKeyCode == 187)		// '+' key on Windows
						{
							this.ZoomIn();
						}
						else if (e.PlatformKeyCode == 189)	// '-' key on Windows
						{
							this.ZoomOut();
						}
						break;
				}
				this.InvokeHandler(this.ReceivedInput);
				e.Handled = true;
			}
		}

		/// <summary>
		/// Handles Click events on the home button.
		/// </summary>
		private void HomeButton_Click(object sender, RoutedEventArgs e)
		{
			this.InvokeHandler(this.ReceivedInput);
			this.ShowAll();
		}

		/// <summary>
		/// Handles Click events on the zoom in button.
		/// </summary>
		private void ZoomInButton_Click(object sender, RoutedEventArgs e)
		{
			this.InvokeHandler(this.ReceivedInput);
			this.ZoomIn();
		}

		/// <summary>
		/// Handles Click events on the zoom out button.
		/// </summary>
		private void ZoomOutButton_Click(object sender, RoutedEventArgs e)
		{
			this.InvokeHandler(this.ReceivedInput);
			this.ZoomOut();
		}

		/// <summary>
		/// Handles Click events on the full screen button.
		/// </summary>
		private void FullScreenButton_Click(object sender, RoutedEventArgs e)
		{
			this.ToggleFullScreen();
		}

		/// <summary>
		/// Handles MouseEnter events on the toolbar.
		/// </summary>
		private void Toolbar_MouseEnter(object sender, MouseEventArgs e)
		{
			VisualStateManager.GoToState(this, "MouseOverToolbar", true);
		}

		/// <summary>
		/// Handles MouseLeave events on the toolbar.
		/// </summary>
		private void Toolbar_MouseLeave(object sender, MouseEventArgs e)
		{
			VisualStateManager.GoToState(this, "Normal", true);
		}

		/// <summary>
		/// Handles ImageFailed events on the main MultiScaleImage element.
		/// </summary>
		private void MultiScaleImage_ImageFailed(object sender, RoutedEventArgs e)
		{
			this.ShowMessage("Could not load image tile.");
		}

		/// <summary>
		/// Handles ImageOpenFailed events on the main MultiScaleImage element. 
		/// </summary>
		private void MultiScaleImage_ImageOpenFailed(object sender, ExceptionRoutedEventArgs e)
		{
			this.ShowMessage(String.Format(CultureInfo.CurrentCulture, "Could not open '{0}'.", this.Source));
		}

		/// <summary>
		/// Handles ImageOpenSucceeded events on the main MultiScaleImage element.
		/// </summary>
		private void MultiScaleImage_ImageOpenSucceeded(object sender, RoutedEventArgs e)
		{
			this.isOpen = true;
			this.isViewportInitialized = false;
			this.messageTextBlock.Visibility = Visibility.Collapsed;
			if (Double.IsNaN(this.ImageAspectRatio))
			{
				this.ImageAspectRatio = this.multiScaleImage.AspectRatio;
				this.LogicalBounds = new Rect(0, 0, 1, 1 / this.ImageAspectRatio);
			}
			this.UpdateMaxZoom();
			this.ShowAll();
			if (this.elementWidth > 0 && this.elementHeight > 0)
			{
				this.InvokeHandler(this.Ready);
			}
		}

		/// <summary>
		/// Handles SizeChanged events on the main MultiScaleImage element.
		/// </summary>
		private void MultiScaleImage_SizeChanged(object sender, SizeChangedEventArgs e)
		{
			this.elementWidth = e.NewSize.Width;
			this.elementHeight = e.NewSize.Height;
			this.UpdateMaxZoom();
			this.emptyMargin = Math.Max(MinEmptyMargin, EmptyMarginFraction * Math.Min(this.elementWidth, this.elementHeight));
			this.isViewportScaleOutdated = true;
			this.isTargetCenterOutdated = true;
			this.IsRenderNeeded = true;
			if (this.elementWidth > 0 && this.elementHeight > 0 && this.isOpen)
			{
				this.InvokeHandler(this.Ready);
			}
		}

		/// <summary>
		/// Handles MouseLeftButtonDown events on the main MultiScaleImage element.
		/// </summary>
		private void MultiScaleImage_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
		{
			if (!e.Handled)
			{
				this.InvokeHandler(this.ReceivedInput);
				this.Focus();
				this.isDragging = this.multiScaleImage.CaptureMouse();
				if (this.isDragging)
				{
					this.mousePosition = e.GetPosition(this.multiScaleImage);
					e.Handled = true;
				}
			}
		}

		/// <summary>
		/// Handles MouseMove events on the main MultiScaleImage element.
		/// </summary>
		private void MultiScaleImage_MouseMove(object sender, MouseEventArgs e)
		{
			var position = e.GetPosition(this.multiScaleImage);
			this.Focus();
			if (this.isDragging)
			{
				this.Pan(position.X - this.mousePosition.X, position.Y - this.mousePosition.Y);
			}
			this.mousePosition = position;
		}

		/// <summary>
		/// Handles MouseLeftButtonUp events on the main MultiScaleImage element.
		/// </summary>
		private void MultiScaleImage_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
		{
			if (!e.Handled && this.isDragging)
			{
				this.multiScaleImage.ReleaseMouseCapture();
				this.isDragging = false;
				e.Handled = true;
			}
		}

		/// <summary>
		/// Handles mouse wheel events on the main MultiScaleImage element.
		/// </summary>
		private void MultiScaleImage_MouseWheel(object sender, MouseWheelEventArgs e)
		{
			if (!e.Handled && this.isOpen)
			{
				this.InvokeHandler(this.ReceivedInput);
				var zoomFactor = e.Delta > 0 ? ZoomChangeFactor : 1 / ZoomChangeFactor;
				var p = this.multiScaleImage.ElementToLogicalPoint(this.mousePosition);
				p = this.ClosestPoint(p);
				this.ZoomBy(zoomFactor, p);
				e.Handled = true;
			}
		}

		/// <summary>
		/// Calculates the maximum zoom factor allowed for a given size of the MultiScaleImage element.
		/// </summary>
		/// <param name="elementSize">The size of the MultiScaleImage element.</param>
		private void UpdateMaxZoom()
		{
			// Only perform the calculation if the element has been laid out already
			// (so its size is non-zero), and if the full-resolution image size is known.
			if (this.elementWidth > 0 && this.elementHeight > 0 && this.ImageWidth > 0 && this.ImageHeight > 0)
			{
				this.maxZoom = Math.Max(1, this.maxPixelMagnification * this.ImageWidth / this.elementWidth);
			}
			else
			{
				this.maxZoom = DefaultMaxZoom;
			}
			this.TargetZoom = this.targetZoom;
		}

		/// <summary>
		/// Toggles between full screen and browser-hosted state.
		/// </summary>
		private void ToggleFullScreen()
		{
			var content = Application.Current.Host.Content;
			content.IsFullScreen = !content.IsFullScreen;
			ToolTipService.SetToolTip(this.fullScreenButton, content.IsFullScreen ? "Exit full screen (Esc)" : "Enter full screen (F)");
		}

		/// <summary>
		/// Centers the image and changes the zoom so that the entire image is just visible.
		/// </summary>
		private void ShowAll()
		{
			this.TargetZoom = 1;
			this.TargetCenter = this.ClosestPoint(new Point(this.LogicalBounds.Left + 0.5 * this.LogicalBounds.Width, this.LogicalBounds.Top + 0.5 * this.LogicalBounds.Height));
		}

		/// <summary>
		/// Zooms in about the center of the main MultiScaleImage element.
		/// </summary>
		private void ZoomIn()
		{
			if (this.isOpen)
			{
				var p = new Point(this.multiScaleImage.ActualWidth / 2, this.multiScaleImage.ActualHeight / 2);
				p = this.multiScaleImage.ElementToLogicalPoint(p);
				p = this.ClosestPoint(p);
				this.ZoomBy(ZoomChangeFactor, p);
			}
		}

		/// <summary>
		/// Zooms out about the center of the main MultiScaleImage element.
		/// </summary>
		private void ZoomOut()
		{
			if (this.isOpen)
			{
				var p = new Point(this.multiScaleImage.ActualWidth / 2, this.multiScaleImage.ActualHeight / 2);
				p = this.multiScaleImage.ElementToLogicalPoint(p);
				p = this.ClosestPoint(p);
				this.ZoomBy(1 / ZoomChangeFactor, p);
			}
		}

		/// <summary>
		/// Pans (translates) the image by the given number of screen pixels.
		/// </summary>
		/// <param name="deltaX">The number of pixels to pan in the horizontal direction.</param>
		/// <param name="deltaY">The number of pixels to pan in the vertical direction.</param>
		private void Pan(double deltaX, double deltaY)
		{
			var scale = this.multiScaleImage.ViewportWidth / this.multiScaleImage.ActualWidth;
			this.targetCenter.X -= scale * deltaX;
			this.targetCenter.Y -= scale * deltaY;
			this.isTargetCenterOutdated = true;
			this.IsRenderNeeded = true;
		}

		/// <summary>
		/// Zooms by the given factor about the specified point.
		/// </summary>
		/// <param name="zoomFactor">The factor by which to multiply the current zoom.</param>
		/// <param name="p">The point (in logical coordinates) about which to zoom.</param>
		private void ZoomBy(double zoomFactor, Point p)
		{
			var newZoom = this.ClampZoom(zoomFactor * this.targetZoom);
			var scale = this.targetZoom / newZoom;
			var newCenter = new Point(p.X + (this.targetCenter.X - p.X) * scale, p.Y + (this.targetCenter.Y - p.Y) * scale);
			this.TargetZoom = newZoom;
			this.TargetCenter = newCenter;
		}

		/// <summary>
		/// Returns the given point, after transforming it into the currently centered instance
		/// of the image if horizontal or vertical wrapping is enabled.
		/// </summary>
		/// <param name="p">A point in logical coordinates.</param>
		/// <returns>The equivalent point in the currently centered instance of the image.</returns>
		private Point ClosestPoint(Point p)
		{
			if (this.DisplayMode == DisplayMode.Horizontal360)
			{
				var delta = p.X - this.currentCenter.X;
				var logicalWidth = this.LogicalBounds.Width;
				delta -= logicalWidth * Math.Round(delta / logicalWidth);
				p.X = this.currentCenter.X + delta;
			}
			else if (this.DisplayMode == DisplayMode.Vertical360)
			{
				var delta = p.Y - this.currentCenter.Y;
				var logicalHeight = this.LogicalBounds.Height;
				delta -= logicalHeight * Math.Round(delta / logicalHeight);
				p.Y = this.currentCenter.Y + delta;
			}
			return p;
		}

		/// <summary>
		/// Returns the specified zoom value after clamping it to lie between minZoom and maxZoom.
		/// </summary>
		/// <param name="newZoom">The new zoom value.</param>
		/// <returns>A clamped zoom value.</returns>
		private double ClampZoom(double newZoom)
		{
			return Math.Max(this.minZoom, Math.Min(newZoom, this.maxZoom));
		}

		/// <summary>
		/// Invokes the specified event handler.
		/// </summary>
		private void InvokeHandler(EventHandler handler)
		{
			if (handler != null)
			{
				handler(this, EventArgs.Empty);
			}
		}

		/// <summary>
		/// Handles the Rendering event that is raised whenever Silverlight is about to render.
		/// </summary>
		/// <remarks>This method is responsible for updating the viewports of the
		/// MultiScaleImage elements.  It simulates friction or drag by interpolating between
		/// the current viewport (this.currentCenter and this.currentZoom) and the
		/// desired viewport (this.targetCenter and this.targetZoom).</remarks>
		private void CompositionTarget_Rendering(object sender, EventArgs e)
		{
			// Only do work if the image has been loaded and the elements have been laid out.
			bool isDoneMoving = true;
			if (this.isOpen && this.elementWidth > 0 && this.elementHeight > 0)
			{
				if (this.isViewportScaleOutdated)
				{
					this.UpdateViewportScale();
					this.isViewportScaleOutdated = false;
				}

				if (this.isTargetCenterOutdated)
				{
					this.targetCenter = this.ClampCenter(this.targetCenter, this.targetZoom);
					this.isTargetCenterOutdated = false;
				}

				// Update the current viewport.
				if (!this.isViewportInitialized)
				{
					// The first rendering uses the target viewport directly.
					this.currentCenter = this.targetCenter;
					this.currentZoom = this.targetZoom;
					this.isViewportInitialized = true;
				}
				else
				{
					// Subsequent renderings use a drag coefficient to simulate friction, causing
					// the current viewport to smoothly catch up to the target viewport.
					var oneMinusDrag = 1 - DragCoefficient;
					var newCenterX = DragCoefficient * this.currentCenter.X + oneMinusDrag * this.targetCenter.X;
					var newCenterY = DragCoefficient * this.currentCenter.Y + oneMinusDrag * this.targetCenter.Y;
					var newZoom = 1 / (DragCoefficient / this.currentZoom + oneMinusDrag / this.targetZoom);

					// Keep animating until the changes in center and zoom fall below detectable thresholds.
					var pixelScale = this.elementWidth * newZoom / this.viewportScale;
					var deltaX = Math.Abs(newCenterX - this.currentCenter.X) * pixelScale;
					var deltaY = Math.Abs(newCenterY - this.currentCenter.Y) * pixelScale;
					var relativeZoomChange = Math.Abs(newZoom - this.currentZoom) / this.currentZoom;
					isDoneMoving = deltaX < PixelChangeThreshold && deltaY < PixelChangeThreshold && relativeZoomChange < ZoomChangeThreshold;

					// Set the current center and zoom.
					this.currentCenter = new Point(newCenterX, newCenterY);
					this.currentZoom = newZoom;
				}

				// Update the rendering.
				this.Render();
			}
			else
			{
				// The image hasn't been opened or the elements haven't been laid out yet.
				// Just store the target viewport for later use.
				this.currentCenter = this.targetCenter;
				this.currentZoom = this.targetZoom;
			}

			// See how much time has gone by since the last interaction.
			if (isDoneMoving)
			{
				// Enough time has elapsed that we no longer need to keep rendering.
				this.IsRenderNeeded = false;
			}
		}

		/// <summary>
		/// Calculates viewportScale based on element width, element height, empty margin,
		// image aspect ratio, and display mode.
		/// </summary>
		private void UpdateViewportScale()
		{
			// Calculate viewportScale to compensate for the difference between
			// image aspect ratio and element aspect ratio.  Leave space for margins if the
			// image is not periodic in the dimension constrained by the element aspect ratio.
			this.viewportScale = this.LogicalBounds.Width;
			if (this.isOpen && this.elementWidth > 0 && this.elementHeight > 0)
			{
				var elementAspectRatio = this.elementWidth / this.elementHeight;
				if (this.imageAspectRatio < elementAspectRatio)
				{
					// The element has a wider aspect ratio than the image.
					// Make sure the full height of the unzoomed image fits within the element.
					this.viewportScale *= elementAspectRatio / this.imageAspectRatio;
					if (this.DisplayMode != DisplayMode.Vertical360)
					{
						// It's not a vertical 360, so make room for top and bottom margins.
						this.viewportScale *= this.elementHeight / (this.elementHeight - 2 * this.emptyMargin);
					}
				}
				else if (this.DisplayMode != DisplayMode.Horizontal360)
				{
					// The element has a taller aspect ratio than the image.
					// It's not a horizontal 360, so make room for left and right margins.
					this.viewportScale *= this.elementWidth / (this.elementWidth - 2 * this.emptyMargin);
				}

				// When the image is a 360-degree panorama, allow at most one whole image to show.
				if (this.DisplayMode == DisplayMode.Horizontal360)
				{
					this.viewportScale = Math.Min(this.LogicalBounds.Width, this.viewportScale);
				}
				else if (this.DisplayMode == DisplayMode.Vertical360)
				{
					this.viewportScale = Math.Min(this.LogicalBounds.Height * elementAspectRatio, this.viewportScale);
				}

				// Make sure the scale is never negative.
				this.viewportScale = Math.Max(0, this.viewportScale);
			}
		}

		/// <summary>
		/// Returns a center point after adjusting to make sure the whole image remains visible
		/// if possible for the specified zoom.
		/// </summary>
		/// <param name="center">The desired center point.</param>
		/// <param name="zoom">The desired zoom.</param>
		/// <returns>An adjusted center point.</returns>
		private Point ClampCenter(Point center, double zoom)
		{
			if (this.isOpen && this.elementWidth > 0 && this.elementHeight > 0)
			{
				// Compute the viewport origin and the scaling between element pixels and logical coordinates.
				var elementAspectRatio = this.elementWidth / this.elementHeight;
				var viewportWidth = this.viewportScale / zoom;
				var viewportOrigin = new Point(center.X - 0.5 * viewportWidth, center.Y - 0.5 * viewportWidth / elementAspectRatio);
				var pixelScale = this.elementWidth / viewportWidth;

				// Adjust the viewport origin and center to account for margins.
				// First, take care of the horizontal direction.
				if (this.DisplayMode != DisplayMode.Horizontal360)
				{
					// Calculate where the left and right edges of the image would appear.
					var left = (this.LogicalBounds.Left - viewportOrigin.X) * pixelScale;
					var right = (this.LogicalBounds.Right - viewportOrigin.X) * pixelScale;
					if (right - left < this.elementWidth - 2 * this.emptyMargin)
					{
						// The entire image width is visible.  Force it to be centered.
						center.X = this.LogicalBounds.Left + 0.5 * this.LogicalBounds.Width;
					}
					else if (left > this.emptyMargin)
					{
						// We were going to show too much empty space on the left.
						// Force the left edge of the image to match the margin.
						viewportOrigin.X = this.LogicalBounds.Left - this.emptyMargin / pixelScale;
						center.X = viewportOrigin.X + 0.5 * viewportWidth;
					}
					else if (right < this.elementWidth - this.emptyMargin)
					{
						// We were going to show too much empty space on the right.
						// Force the right edge of the image to match the margin.
						viewportOrigin.X = this.LogicalBounds.Right - (this.elementWidth - this.emptyMargin) / pixelScale;
						center.X = viewportOrigin.X + 0.5 * viewportWidth;
					}
				}

				// Now take care of the vertical direction.
				if (this.DisplayMode != DisplayMode.Vertical360)
				{
					// Calculate where the top and bottom edges of the image would appear.
					var top = (this.LogicalBounds.Top - viewportOrigin.Y) * pixelScale;
					var bottom = (this.LogicalBounds.Bottom - viewportOrigin.Y) * pixelScale;
					if (bottom - top < this.elementHeight - 2 * this.emptyMargin)
					{
						// The entire image height is visible.  Force it to be centered.
						center.Y = this.LogicalBounds.Top + 0.5 * this.LogicalBounds.Height;
					}
					else if (top > this.emptyMargin)
					{
						// We were going to show too much empty space on the top.
						// Force the top edge of the image to match the margin.
						viewportOrigin.Y = this.LogicalBounds.Top - this.emptyMargin / pixelScale;
						center.Y = viewportOrigin.Y + 0.5 * viewportWidth / elementAspectRatio;
					}
					else if (bottom < this.elementHeight - this.emptyMargin)
					{
						// We were going to show too much empty space on the bottom.
						// Force the bottom edge of the image to match the margin.
						viewportOrigin.Y = this.LogicalBounds.Bottom - (this.elementHeight - this.emptyMargin) / pixelScale;
						center.Y = viewportOrigin.Y + 0.5 * viewportWidth / elementAspectRatio;
					}
				}
			}
			return center;
		}

		/// <summary>
		/// Updates the viewports of the MultiScaleImage elements based on the current values of
		/// viewportScale, currentZoom, and currentCenter.
		/// </summary>
		private void Render()
		{
			if (this.isOpen && this.elementWidth > 0 && this.elementHeight > 0)
			{
				this.isViewportInitialized = true;

				// Calculate the viewport width and viewport origin from the current center and zoom.
				var viewportWidth = this.viewportScale / this.currentZoom;
				var elementAspectRatio = this.elementWidth / this.elementHeight;
				var viewportOrigin = new Point(this.currentCenter.X - 0.5 * viewportWidth, this.currentCenter.Y - 0.5 * viewportWidth / elementAspectRatio);

				// Update the viewports of the MultiScaleImage elements.
				if (this.DisplayMode == DisplayMode.Normal)
				{
					// We're only dealing with one MultiScaleImage.  Set its viewport directly.
					this.multiScaleImage.ViewportWidth = viewportWidth;
					this.multiScaleImage.ViewportOrigin = viewportOrigin;
				}
				else
				{
					// The image is periodic, so we'll calculate two different viewport origins,
					// spaced so that the image copies overlap by exactly one pixel.
					var pixelScale = this.elementWidth / viewportWidth;
					var viewportOrigins = new Point[2];
					viewportOrigins[0] = viewportOrigins[1] = viewportOrigin;
					bool isMultiScaleImage2InFront = true;
					if (this.DisplayMode == DisplayMode.Horizontal360)
					{
						var interval = (int)Math.Floor(viewportOrigin.X / this.LogicalBounds.Width);
						var fraction = viewportOrigin.X - this.LogicalBounds.Width * interval;
						var index = Math.Abs(interval % 2);
						var offset = this.LogicalBounds.Width - 1 / pixelScale;
						viewportOrigins[index].X = fraction;
						viewportOrigins[1 - index].X = fraction - offset;
						isMultiScaleImage2InFront = index == 0;
					}
					else
					{
						var interval = (int)Math.Floor(viewportOrigin.Y / this.LogicalBounds.Height);
						var fraction = viewportOrigin.Y - this.LogicalBounds.Height * interval;
						var index = Math.Abs(interval % 2);
						var offset = this.LogicalBounds.Height - 1 / pixelScale;
						viewportOrigins[index].Y = fraction;
						viewportOrigins[1 - index].Y = fraction - offset;
						isMultiScaleImage2InFront = index == 0;
					}

					// Set the viewports of both MultiScaleImage elements.
					this.multiScaleImage.ViewportWidth = viewportWidth;
					this.multiScaleImage.ViewportOrigin = viewportOrigins[0];
					Canvas.SetZIndex(this.multiScaleImage, isMultiScaleImage2InFront ? 0 : 1);
					this.multiScaleImage2.ViewportWidth = viewportWidth;
					this.multiScaleImage2.ViewportOrigin = viewportOrigins[1];
					Canvas.SetZIndex(this.multiScaleImage2, isMultiScaleImage2InFront ? 1 : 0);
				}

				// Invoke the ViewChanged event handler.
				this.InvokeHandler(this.ViewChanged);
			}
		}

		/// <summary>
		/// Deals with changes to the Source property.
		/// </summary>
		/// <remarks>This method gets complicated because the Source may refer to an XML island
		/// embedded within the web page, or it may point to an external HD View XML file or an
		/// external Deep Zoom XML file.  If it's an embedded XML island, we parse it here.
		/// Otherwise, we use WebClient to start downloading the file.</remarks>
		private void ProcessSourceChange()
		{
			var newSource = this.Source;
			if (newSource != null)
			{
				// Cancel any previous web client download.
				if (this.webClient != null && this.webClient.IsBusy)
				{
					this.webClient.CancelAsync();
				}

				// Indicate that we're trying to open an image.
				this.ShowMessage("Opening image...");

				// Check for an element ID fragment.
				if (newSource.Length > 1 && newSource[0] == '#')
				{
					// The source URI is an element ID fragment.  Look for the script element with that ID.
					var id = newSource.Substring(1);
					if (!HtmlPage.IsEnabled)
					{
						this.ShowMessage(String.Format(CultureInfo.CurrentCulture, "Cannot access HTML page to find script element with ID '{0}'.", id));
						return;
					}
					var scriptElement = HtmlPage.Document.GetElementById(id);
					if (scriptElement == null || scriptElement.TagName != "script")
					{
						this.ShowMessage(String.Format(CultureInfo.CurrentCulture, "This HTML page does not contain a script element with ID '{0}'.", id));
						return;
					}

					// Get the text within the script element.
					// Note that the "text" property works in IE 7, IE 8, and Firefox 3, but not Safari 2.
					// The "innerHTML" property works in all (but note that it's case sensitive in Safari!).
					var text = scriptElement.GetProperty("innerHTML") as string;
					if (String.IsNullOrEmpty(text))
					{
						// If the "innerHTML" property is empty, try the "text" property.
						text = scriptElement.GetProperty("text") as string;
					}

					// Parse the text as XML for an HD View image set.
					Parser parser = new Parser();
					if (parser.Parse(text, HtmlPage.Document.DocumentUri, null))
					{
						this.UseImageSet(parser.ImageSet);
					}
					else
					{
						this.ShowMessage(String.Format(CultureInfo.CurrentCulture, "Error in content of script element with ID '{0}': {1}", id, parser.ErrorMessage));
					}
				}
				else 
				{
					// See if the source is actually the XML for an HD View image set.
					var baseUri = HtmlPage.IsEnabled ? HtmlPage.Document.DocumentUri : Application.Current.Host.Source;
					Parser parser = new Parser();
					if (parser.Parse(newSource, baseUri, null))
					{
						this.UseImageSet(parser.ImageSet);
					}
					else
					{
						// The source URI is not an element ID fragment or raw XML.
						// Split it into an actual URL and an XPath query.
						string xpath = null;
						int index = newSource.LastIndexOf("//", StringComparison.Ordinal);
						if (index > 0 && newSource[index - 1] != '/' && newSource[index - 1] != ':')
						{
							xpath = newSource.Substring(index);
							newSource = newSource.Substring(0, index);
						}

						// Get the absolute URI.
						this.webClient = new WebClient();
						Uri absoluteSource;
						if (!Uri.TryCreate(newSource, UriKind.Absolute, out absoluteSource))
						{
							absoluteSource = new Uri(baseUri, newSource);
						}

						// Make sure we can access the file.
						if (absoluteSource.Scheme != baseUri.Scheme)
						{
							this.ShowMessage(String.Format(CultureInfo.CurrentCulture, "Cannot access the XML file using a '{0}' URL when the HTML page is accessed using a '{1}' URL.", absoluteSource.Scheme, baseUri.Scheme));
						}
						else if (absoluteSource.Scheme == "file")
						{
							// Only Deep Zoom can read XML from local disk.
							this.UseDeepZoomSource(new Uri(newSource, UriKind.RelativeOrAbsolute));
						}
						else
						{
							// Start downloading the file.
							this.webClient.DownloadStringCompleted += this.WebClient_DownloadStringCompleted;
							this.webClient.DownloadStringAsync(absoluteSource, new object[] { absoluteSource, xpath });
						}
					}
				}
			}
		}

		/// <summary>
		/// Handles the DownloadStringCompleted event when WebClient finishes downloading an XML file.
		/// </summary>
		private void WebClient_DownloadStringCompleted(object sender, DownloadStringCompletedEventArgs e)
		{
			if (!e.Cancelled)
			{
				var state = (object[])e.UserState;
				var newSource = state[0] as Uri;
				var xpath = state[1] as string;
				if (e.Error != null)
				{
					// We couldn't download the XML file, but maybe Deep Zoom can.
					this.UseDeepZoomSource(newSource);
				}
				else
				{
					// See if we can parse the downloaded file as an HD View image set.
					Parser parser = new Parser();
					if (parser.Parse(e.Result, newSource, xpath))
					{
						// It's an HD View image set.
						this.UseImageSet(parser.ImageSet);
					}
					else
					{
						// We couldn't parse the XML file as an HD View image set.  Maybe it's a Deep Zoom XML file.
						this.UseDeepZoomSource(newSource);
					}
				}
			}
		}

		/// <summary>
		/// Initializes the two MultiScaleImage elements to use a Deep Zoom tile source.
		/// </summary>
		/// <param name="source">The URI of the Deep Zoom XML file.</param>
		private void UseDeepZoomSource(Uri newSource)
		{
			this.ImageSet = null;
			this.ImageAspectRatio = Double.NaN;
			this.multiScaleImage.Source = new DeepZoomImageTileSource(newSource);
			this.multiScaleImage2.Source = new DeepZoomImageTileSource(newSource);
		}

		/// <summary>
		/// Initializes the two MultiScaleImage elements to use a custom tile source.
		/// </summary>
		/// <param name="imageSet">The HD View image set.</param>
		private void UseImageSet(HDViewImageSet imageSet)
		{
			// Initialize the image set.
			imageSet.Initialize();

			// Store the image's width, height, aspect ratio, and maximum magnification.
			this.ImageSet = imageSet;
			this.ImageWidth = imageSet.Width;
			this.ImageHeight = imageSet.Height;
			this.ImageAspectRatio = (double)imageSet.Width / imageSet.Height;
			this.maxPixelMagnification = Math.Max(1, imageSet.MaxZoom);

			// Calculate the logical bounds of the image in MultiScaleImage viewport coordinates.
			double logicalWidth = (double)imageSet.Width / imageSet.PaddedWidth;
			double logicalHeight = logicalWidth / this.ImageAspectRatio;
			double logicalLeft = 0;
			double paddedLogicalHeight = (double)imageSet.PaddedHeight / imageSet.PaddedWidth;
			double logicalTop = imageSet.IsLowerLeftOrigin ? paddedLogicalHeight - logicalHeight : 0;
			this.LogicalBounds = new Rect(logicalLeft, logicalTop, logicalWidth, logicalHeight);

			// Set the display mode.
			switch (imageSet.Projection)
			{
				case HDViewProjection.Cylindrical:
				case HDViewProjection.Spherical:
					// It's a horizontal 360-degree panorama if ThetaMax - ThetaMin is approximately 360.
					this.DisplayMode = Math.Abs(imageSet.ThetaMax - imageSet.ThetaMin - 360) <= AngleToleranceFor360 ? DisplayMode.Horizontal360 : DisplayMode.Normal;
					break;
				case HDViewProjection.CylindricalTransverse:
				case HDViewProjection.SphericalTransverse:
					// It's a vertical 360-degree panorama if PhiMax - PhiMin is approximately 360.
					this.DisplayMode = Math.Abs(imageSet.PhiMax - imageSet.PhiMin - 360) <= AngleToleranceFor360 ? DisplayMode.Vertical360 : DisplayMode.Normal;
					break;
				default:
					this.DisplayMode = DisplayMode.Normal;
					break;
			}

			// Set the tile sources for both MultiScaleImages.
			this.multiScaleImage.Source = new HDViewTileSource(imageSet);
			this.multiScaleImage2.Source = new HDViewTileSource(imageSet);
		}

		/// <summary>
		/// Displays a message.
		/// </summary>
		/// <param name="message">The message to display.</param>
		private void ShowMessage(string message)
		{
			this.messageTextBlock.Text = message;
			this.messageTextBlock.Visibility = Visibility.Visible;
		}
	}
}
